Skip to content

feat(api): Track 3.2 — X-Admin-Key gates cross-user DELETE on /api/analysis-runs - #37

Merged
slittycode merged 2 commits into
mainfrom
claude/track3-admin-key-5XA5r
May 13, 2026
Merged

feat(api): Track 3.2 — X-Admin-Key gates cross-user DELETE on /api/analysis-runs#37
slittycode merged 2 commits into
mainfrom
claude/track3-admin-key-5XA5r

Conversation

@slittycode

Copy link
Copy Markdown
Owner

Second of four pieces from Track 3. Adds an operator-level privilege gate to DELETE /api/analysis-runs/{run_id} so operators can purge runs that don't belong to them.

Semantics

The admin key bypasses ownership rather than replacing it. Users can still delete their own runs without the header — existing behavior is preserved. Operators with the configured key can delete any run.

SONIC_ANALYZER_ADMIN_KEY set? X-Admin-Key header matches? Effect
❌ no (default) Header is ignored; admin path is closed. Ownership is the only gate.
✅ yes ❌ no/wrong Header is ignored; falls back to ownership. Non-owner gets RUN_NOT_FOUND.
✅ yes ✅ yes Ownership check skipped. Any run can be deleted by the admin.

RUN_NOT_FOUND is still returned for nonexistent runs regardless of the admin key — the admin path cannot be used to enumerate run IDs (response is identical whether the run doesn't exist or the caller is denied).

Response now includes "deletedBy": "owner" | "admin" so audit logs can distinguish the two paths.

Security details

  • hmac.compare_digest for constant-time comparison — resists timing-based probing of the configured key. There's a test that verifies this is actually wired in (so nobody "simplifies" it back to ==).
  • Both the env value and the header value are whitespace-stripped before comparison so secret-manager trailing newlines don't break matching.
  • Empty string and whitespace-only env values are treated as "unset" so a misconfigured deployment doesn't accidentally open the admin path with a trivial key.

Files

File Change
apps/backend/auth_context.py Two new pure functions: admin_key_is_configured() (presence check for routing decisions) and admin_key_matches(provided) (the auth decision). ADMIN_KEY_ENV_VAR constant at module scope.
apps/backend/server.py delete_analysis_run accepts X-Admin-Key header. When it matches, runtime.get_run(owner_user_id=None) skips the ownership check.
apps/backend/tests/test_auth_context.py (new, 11 tests) Pure-logic: env presence detection (unset/empty/whitespace/set), match decision (none/empty/exact/case-mismatch/partial/whitespace-stripped), and a regression gate that hmac.compare_digest is actually called.
apps/backend/tests/test_server.py (+3 tests, 138 lines) Route-level: admin key bypasses ownership for non-owner; wrong key falls back to ownership (non-owner gets RUN_NOT_FOUND); env var unset closes the admin path even with a header. Existing test_delete_analysis_run_removes_owned_run test extended to assert deletedBy: "owner".
CLAUDE.md / ARCHITECTURE.md Documented as backend-only env var.

Verification

11 auth_context tests pass locally (Python stdlib only):

$ python -m unittest tests.test_auth_context
...........
Ran 11 tests in 0.005s
OK

3 route tests run via the existing AnalysisRuntime + patch.object(server, "get_analysis_runtime", ...) pattern. CI will exercise them.

What this PR does NOT do

  • No admin auth on other routes. The review's recommendation was for DELETE and a "forthcoming admin list." This PR scopes to DELETE only. Adding X-Admin-Key to a bulk-list / stats endpoint is a separate follow-up if/when those endpoints exist.
  • No audit log persistence. The deletedBy field is in the response but isn't written to an audit table. The existing analysis_runs row is deleted; an admin audit trail is a hosted-mode feature for a later PR.
  • No rate limiting on admin requests. Operator clients are trusted.

Track 3 sequencing

https://claude.ai/code/session_01Uh2eNT1oqjixcox8UX3BTg


Generated by Claude Code

…alysis-runs

Second of four pieces from Track 3. Adds an operator-level privilege
gate to DELETE /api/analysis-runs/{run_id}.

Semantics:

- Admin key BYPASSES ownership (does not replace it). Users can still
  delete their own runs without the header — current behavior preserved.
- New SONIC_ANALYZER_ADMIN_KEY env var. If unset (default, local mode),
  no admin path exists and ownership remains the only gate.
- If set, requests carrying matching X-Admin-Key header can delete any
  run regardless of owner. Comparison uses hmac.compare_digest for
  constant-time resistance to timing-based key probing.
- Both env value and header value are whitespace-stripped before
  compare so secret-manager trailing newlines don't break matching.
- Response now includes 'deletedBy': 'owner' | 'admin' so audit logs
  can distinguish the two paths.

Files:

- apps/backend/auth_context.py — two new pure functions:
  admin_key_is_configured() (env-var presence check, for routing
  decisions) and admin_key_matches(provided) (the auth decision).
  ADMIN_KEY_ENV_VAR constant published at module scope.

- apps/backend/server.py — delete_analysis_run accepts X-Admin-Key
  header. When it matches, runtime.get_run(owner_user_id=None) skips
  the ownership check. 404 RUN_NOT_FOUND is still returned for non-
  existent runs (no run-id enumeration via the admin path).

- apps/backend/tests/test_auth_context.py (new, 11 tests, all pass
  locally) — covers admin_key_is_configured (unset, empty, whitespace,
  set) and admin_key_matches (env unset, none provided, exact match,
  case mismatch, partial match, whitespace stripping, hmac.compare_digest
  is actually wired in).

- apps/backend/tests/test_server.py (+138 lines, 3 tests) — covers
  admin-key bypasses ownership for non-owner caller; wrong key falls
  back to ownership (returns RUN_NOT_FOUND for non-owner); env var
  unset closes the admin path even with a header.

- CLAUDE.md / ARCHITECTURE.md — documented as backend-only env var.

Out of scope (deferred):

- X-Admin-Key on a forthcoming bulk-list endpoint
- X-Admin-Key on /interpretations / spectral-enhancements (admin
  doesn't have a clear use case there yet)

https://claude.ai/code/session_01Uh2eNT1oqjixcox8UX3BTg

@slittycode slittycode left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE

Summary

Adds an operator-level X-Admin-Key gate to DELETE /api/analysis-runs/{run_id}. The implementation is correctly partitioned: two pure functions in auth_context.py handle all auth logic; server.py is just routing. All 11 test_auth_context tests pass locally. Three route-level tests cover the admin-bypass, wrong-key-fallback, and unset-env-var paths; they require FastAPI's venv to run, which isn't present in this environment, but the test logic is correct from the diff.

Findings

Worth considering:

admin_key_is_configured() is defined and documented but not called in server.py — only admin_key_matches() is used. Fine; the docstring frames it as a routing-decision helper for future conditional route exposure. Noting it so nobody removes it thinking it's dead code.

The test_delete_analysis_run_admin_key_unset_ignores_header test calls server.os.environ.pop("SONIC_ANALYZER_ADMIN_KEY", None) inside the patch.dict block to handle environments where the var might already be set. That's the correct approach when patch.dict(clear=False) is in use — no issue.

Test results

11 passed / 0 failed (tests.test_auth_context). Server route tests not runnable without FastAPI venv in this environment; auth logic tests are exhaustive.

Phase boundary check

Clean. No Phase 1 measurements touched. No Phase 2/3 files modified. Purely infra.


Generated by Claude Code

@slittycode slittycode left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: APPROVE

Summary

Adds an optional operator-level privilege gate (SONIC_ANALYZER_ADMIN_KEY) to DELETE /api/analysis-runs/{run_id}. Default behavior is fully unchanged — when the env var is unset the admin path is closed and ownership is the only gate. The security-critical comparison uses hmac.compare_digest and the test suite includes a regression guard that it stays wired in. Test coverage is solid: 11 pure-logic tests in the new test_auth_context.py plus 3 new route-level integration tests. Both Backend and Frontend CI checks pass.

Findings

Worth considering (non-blocking):

server.py now has both import auth_context (new, line ~29) and from auth_context import AuthenticationRequiredError, UserContext, resolve_api_user_context (existing). The module-level import is used so the call site reads auth_context.admin_key_matches(...) rather than a bare function — reasonable intentional choice, not a bug.

The admin-bypass test exercises the "different user + matching key" case. The PR description's table also lists "no user header at all" as a valid admin scenario; that sub-case isn't separately tested. The code path is identical (both skip _resolve_route_user_context), so this isn't a correctness gap — mentioning in case you want the coverage to match the table.

Test results

Backend: 11 new auth_context tests + 3 new server route tests + 1 assertion added to existing delete test. All pass. CI: Backend ✅ Frontend ✅.

Phase boundary check

Clean. No changes to Phase 1 DSP code, Phase 1 output schema, or Phase 2/3 interpretation logic.


Generated by Claude Code

Adds the coverage case flagged in PR #37 review: the PR description
table claims the admin key bypasses user-context resolution entirely.
This pins that claim with x_asa_user_id=None — a request that would
fail under hosted-mode ownership rules still succeeds when the admin
key matches.

Not a correctness fix; the code path was already identical to the
existing 'different user + matching key' test. Closing the doc/test
asymmetry so the PR table is verifiable.

https://claude.ai/code/session_01Uh2eNT1oqjixcox8UX3BTg
@slittycode
slittycode merged commit 5ebf518 into main May 13, 2026
2 checks passed
@slittycode
slittycode deleted the claude/track3-admin-key-5XA5r branch May 13, 2026 22:11
slittycode added a commit that referenced this pull request May 14, 2026
Fourth and final piece of Track 3 from the merged external-repo review.
Additive over the existing 8-state stage status — does not change or
remove anything.

What's new:

- apps/backend/stage_status.py (new) — pure module with the canonical
  8 -> 5 mapping table, the to_public_status() helper, and
  PUBLIC_STATUS_VALUES exported for type generation / docs.

  Mapping (internal -> public):
    queued        -> queued
    running       -> running
    blocked       -> queued        (transient scheduling state)
    ready         -> queued        (scheduled but not yet running)
    completed     -> completed
    failed        -> failed
    interrupted   -> interrupted
    not_requested -> null          (stage not in pipeline)

- apps/backend/server_phase1.py — _normalize_run_snapshot now also
  annotates each stage with publicStatus via the new
  _annotate_public_status helper. The original status field is
  preserved untouched.

- apps/backend/tests/test_stage_status.py (new, 16 tests) — pins
  every cell of the mapping, the public-value set, defensive cases
  (None, unknown, empty, case-sensitive), and a completeness check
  that fails if the set of internal states drifts without an explicit
  mapping update.

- apps/backend/tests/test_server.py (+1 test) — integration test
  that GET /api/analysis-runs/{run_id} returns publicStatus on all
  three stages with the correct values for a fresh run.

- apps/ui/src/types/backend.ts — new PublicStageStatus type (the
  5-value union). publicStatus: PublicStageStatus | null added to
  MeasurementStageSnapshot, PitchNoteTranslationStageSnapshot,
  InterpretationStageSnapshot. attempt summaries unchanged
  (publicStatus is only on top-level stages).

- apps/ui/src/services/analysisRunsClient.ts — new
  parsePublicStageStatus helper + PUBLIC_STAGE_STATUSES set; threaded
  into the three stage parsers. The helper accepts null and missing
  values without throwing so legacy snapshots that pre-date the
  field continue to deserialize.

- docs/adr/0001-phase1-json-schema-v1.md — documents the additive
  field, the mapping table, and the rationale (internal scheduling
  states are scheduling concerns the client doesn't need to act on).

Out of scope (deferred):

- Removing publicStatus duplication from the internal status field
  later. The additive approach was the explicit choice; collapsing
  to one field would be a breaking change requiring a v2 schema bump.
- Frontend UI that reads publicStatus. Existing UI continues to use
  the 8-state status field; publicStatus is the additive surface for
  future external consumers and any new UI views that prefer the
  smaller vocabulary.

This closes Track 3 (all four pieces shipped):
  - 3.1 URL ingestion (#36, merged)
  - 3.2 X-Admin-Key on DELETE (#37, merged)
  - 3.3 GET /source-audio re-serve (#40, merged)
  - 3.4 publicStatus additive (this PR)

https://claude.ai/code/session_01Uh2eNT1oqjixcox8UX3BTg

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants